Skip to content

feat(cli): global -C flag for working-directory switching#2031

Open
fengmk2 wants to merge 50 commits into
mainfrom
feat/cwd-flag
Open

feat(cli): global -C flag for working-directory switching#2031
fengmk2 wants to merge 50 commits into
mainfrom
feat/cwd-flag

Conversation

@fengmk2

@fengmk2 fengmk2 commented Jul 3, 2026

Copy link
Copy Markdown
Member

Bare vp dev/build/preview/pack at a monorepo root used to silently run against the root (a 404 dev server, a build of the wrong thing). Now they resolve a target.

In a terminal, a fuzzy package picker (the same component vp run uses):

$ vp build

Select a package to build (↑/↓, Enter to run, type to search):

  › admin  apps/admin
    web    apps/web
    ui     packages/ui

Typing filters the list. Enter runs the choice as an implicit -C and prints how to skip the prompt next time:

Selected package: web (apps/web)
Tip: run this directly with `vp -C apps/web build`

vite building client environment for production...
✓ built

Non-interactive (CI, pipes), it lists the candidates and exits 1 instead of building the root:

$ vp build
error: `vp build` at the workspace root needs a target package.

  Packages in this workspace:
    admin  apps/admin
    web    apps/web
    ui     packages/ui

  Pass a directory:  vp -C apps/admin build
  Or run every package's build script:  vp run -r build

The picker is built on a new global -C <dir> flag: vp -C <dir> <cmd> is exactly cd <dir> && vp <cmd> for every command. Resolution order for a bare app command: -C or a positional, then defaultPackage in the root config (statically extracted, so it works at framework roots with no vite-plus install), then auto-select when exactly one package is runnable, then the picker in a TTY or the listing + exit 1 without one. A runnable root still runs in place, and positionals keep upstream semantics (Vite [root], tsdown entries).

Implements rfcs/cwd-flag.md. Tested on the PTY snapshot harness with real keystrokes in both the local and global vp flavors.

Requires voidzero-dev/vite-task#510 (configurable selector prompt); the vite-task pin points at that PR and needs a re-bump to vite-task main after it merges.

@fengmk2 fengmk2 self-assigned this Jul 3, 2026
@netlify

netlify Bot commented Jul 3, 2026

Copy link
Copy Markdown

Deploy Preview for viteplus-preview canceled.

Name Link
🔨 Latest commit b0ae86a
🔍 Latest deploy log https://app.netlify.com/projects/viteplus-preview/deploys/6a4f57b3b0f79400084510a9

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

✅ Staging deployment successful!

Preview: https://viteplus-staging.void.app/
Commit: b0ae86a

@fengmk2

fengmk2 commented Jul 4, 2026

Copy link
Copy Markdown
Member Author

Manual verification steps

Setup

git fetch origin && git checkout feat/cwd-flag
pnpm install && pnpm bootstrap-cli   # installs this branch as the global vp
vp --help | grep -- '-C'             # the new global flag is listed

(pnpm bootstrap-cli replaces the installed ~/.vite-plus CLI; re-run it from main afterwards to restore.)

Create a scratch monorepo with no local vite-plus install, so the branch's bundled CLI runs end to end:

mkdir -p /tmp/vp-c-demo/apps/web /tmp/vp-c-demo/apps/admin /tmp/vp-c-demo/packages/ui/src && cd /tmp/vp-c-demo
echo '{ "name": "demo", "private": true, "workspaces": ["apps/*", "packages/*"] }' > package.json
for a in web admin; do
  echo "{ \"name\": \"$a\", \"private\": true }" > apps/$a/package.json
  echo "<!doctype html><h1>$a</h1>" > apps/$a/index.html
done
echo '{ "name": "ui", "private": true, "type": "module", "main": "src/index.ts" }' > packages/ui/package.json
echo 'export const ui = 1' > packages/ui/src/index.ts

1. -C runs any command as if started in the directory

vp -C packages/ui pack        # packs ui; dist lands in packages/ui/dist
vp -C apps/web dev            # dev server for web, from the root
vp -C apps/web build
vp -C nope build              # error: directory not found: nope (exit 1)

Compare with cd packages/ui && vp pack: identical output. The positional form is unchanged: vp pack packages/ui still treats the path as a tsdown entry resolved from the invocation directory (upstream semantics).

2. Bare app commands at the workspace root

vp build         # error + package listing (apps ranked first) + -C hints, exit 1
vp dev           # same; no server starts against the root anymore
vp dev --help    # still prints vite's dev help, never the listing
vp build | cat   # non-TTY: same listing, deterministic

3. Single-app auto-select (interactive terminal only)

rm -rf apps/admin
vp dev    # prints "Selected package: web (apps/web)" + a Tip line, then starts web

4. defaultPackage

echo 'export default { defaultPackage: "./apps/web" }' > vite.config.ts
vp build            # note: vp build: using ./apps/web (defaultPackage)
vp -C packages/ui pack   # explicit -C still wins over the config
echo 'export default { defaultPackage: "./missing" }' > vite.config.ts
vp build            # error: defaultPackage points to a missing directory (exit 1)

5. The original cwd-divergence bug

The reproduction repo demonstrates the process.cwd() config read that motivated -C:

git clone https://github.com/why-reproductions-are-required/vite-plus-monorepo-app-commands-repro
cd vite-plus-monorepo-app-commands-repro   # do NOT pnpm install: keep the branch CLI in charge
vp dev apps/admin      # still fails with ENOENT (positional = root only, unchanged)
vp -C apps/admin dev   # works: cert loads, server starts

@fengmk2 fengmk2 changed the base branch from main to rfc/interactive-snapshot-tests July 5, 2026 01:50

fengmk2 commented Jul 5, 2026

Copy link
Copy Markdown
Member Author

How to use the Graphite Merge Queue

Add the label auto-merge to this PR to add it to the merge queue.

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@fengmk2 fengmk2 changed the base branch from rfc/interactive-snapshot-tests to graphite-base/2031 July 5, 2026 02:20
@fengmk2 fengmk2 changed the base branch from graphite-base/2031 to rfc/interactive-snapshot-tests July 5, 2026 04:36
@fengmk2 fengmk2 changed the base branch from rfc/interactive-snapshot-tests to graphite-base/2031 July 5, 2026 04:41
@fengmk2 fengmk2 changed the base branch from graphite-base/2031 to rfc/interactive-snapshot-tests July 5, 2026 04:56
@fengmk2 fengmk2 changed the base branch from rfc/interactive-snapshot-tests to graphite-base/2031 July 5, 2026 05:17
@fengmk2 fengmk2 changed the base branch from graphite-base/2031 to rfc/interactive-snapshot-tests July 5, 2026 05:37
@fengmk2 fengmk2 changed the base branch from rfc/interactive-snapshot-tests to graphite-base/2031 July 5, 2026 05:46
@fengmk2 fengmk2 changed the base branch from graphite-base/2031 to rfc/interactive-snapshot-tests July 5, 2026 05:50
@fengmk2 fengmk2 force-pushed the rfc/interactive-snapshot-tests branch from fefbe9d to e6ed290 Compare July 5, 2026 06:10
@fengmk2 fengmk2 changed the base branch from rfc/interactive-snapshot-tests to graphite-base/2031 July 5, 2026 06:44
@fengmk2 fengmk2 changed the base branch from graphite-base/2031 to rfc/interactive-snapshot-tests July 5, 2026 06:52
@fengmk2 fengmk2 changed the base branch from rfc/interactive-snapshot-tests to graphite-base/2031 July 5, 2026 07:03
fengmk2 added 26 commits July 9, 2026 15:21
- defaultPackage is consulted only at the invocation root: a member
  package's own config never redirects (or fails) a command already
  running in that member; regression case pins a member config pointing
  at a missing directory
- inline --env.NAME=value pack flags no longer consume the next token,
  so an explicit entry after them stays a positional
- complete the pack value-flag table from pack-bin.ts: --root, -F/--filter,
  -c/--config; add cac kebab-case twins to the Vite table
- mark the two mid-case expected-failure steps continue-on-failure per the
  harness's new shell-like && semantics
…rkspace

In a non-workspace layout the member IS its own invocation root (standalone
package), where honoring defaultPackage is by design; the finding is about
workspace members, so the case moves to a fixture with a workspaces field
and asserts the pack runs in place.
- rewrite is_bare to mirror cac/mri parsing: a non-flag token after any
  non-boolean flag is that flag's value (required and optional alike, so
  vp dev --host 0.0.0.0 and --open /foo elicit again), and only tokens no
  flag consumes are positionals; the per-tool required-value tables are
  replaced by smaller boolean tables derived from the shipped --help
- pack workspace selectors (-W/--workspace, -F/--filter) define their own
  target set and always bypass elicitation
- document in the RFC that implicit -C runs inside the already-chosen CLI
  (single-version workspace model); full re-delegation for defaultPackage
  targets with their own install/runtime pin is tracked in #2057
The test module imports items explicitly; a restack conflict resolution
kept the tests but dropped the import, which only all-targets compilation
catches (workspace clippy now runs clean with CI's exact invocation).
- pack workspace selectors also bypass elicitation in inline form
  (--filter=ui, --workspace=packages/ui)
- honor the -- option terminator: a token after it is an explicit
  positional, and bare -- stays bare
- global vpr rejects a bare -C with no value instead of delegating it as
  a run argument (mirrors the local wrapper fix)
- local bin.ts catches process.chdir failures (e.g. permission denied on
  an existing directory) as a normal CLI error instead of an uncaught
  stack trace
- render the spread-config build case through the PTY: non-TTY steps
  record raw bytes and the Windows byte stream of a vite build differs
  invisibly from the recorded macOS one
vp pack --root packages/lib at a workspace root was a valid targeted
invocation before this PR; like -W/--workspace and -F/--filter it defines
pack's own target, so it bypasses elicitation (spaced and inline forms)
instead of hitting the picker or the non-TTY listing.
Match the documented convention in packages/prompts/src/milestone.ts: only
the PTY snapshot harness sets the env, so real terminals and piped output
never receive the OSC 8 marker bytes. The picker fixtures keep passing
because the harness sets the gate for spawned processes.
fs.statSync can itself throw (EACCES on a parent, ELOOP) before the
chdir try/catch; chdir reports every failure mode through one catchable
path, with ENOENT/ENOTDIR keeping the directory-not-found message.
- one apply_chdir helper (join/clean/validate/chdir/PWD) replaces the four
  drifting copies in vite_global_cli: main's normal path, the completion
  peek, vpr, and the clap-path fallback in cli.rs — which previously
  skipped set_current_dir and the PWD sync, silently weakening the cd
  equivalence on orderings like a second -C
- one classify() core (bare -> defaultPackage-at-root -> workspace root)
  now feeds both resolve_app_target and needs_elicitation, so the RFC's
  resolution order is written once and the two entry points cannot drift;
  the extracted defaultPackage value rides the enum, removing a duplicate
  config parse
- bin/vpr is a grammar-free shim: bin.ts inserts 'run' itself via argv0
  after its existing -C consumption, deleting the hand-synchronized second
  JS copy of the -C token grammar
- pack runnable check stats src/index.ts before parsing the config (runs
  per workspace package); PWD in the direct-subcommand env is only
  overridden when elicitation retargeted (plain runs keep the caller's
  possibly-symlinked PWD verbatim); vpr parses -C once; identical
  FieldMapInner arms collapse via or-patterns; handler's -C predicate
  simplifies to starts_with
The helper was inserted between normalize_args's bullet-list doc comment
and the function, merging the docs; clippy reads the helper's prose as
unindented list continuations under --deny warnings.
The vp-config e2e broke: a single package with a settings-only
pnpm-workspace.yaml (catalogs, minimumReleaseAge) is a workspace root, and
the runnable-root picker row made the candidate list non-empty, so
non-TTY CI got the target listing and exit 1 for a bare vp pack that
packed fine before this branch.

Deeper rule instead of the row: when the workspace root itself looks
runnable for the command, classify() never elicits and the command runs
in place, exactly the pre-elicitation behavior. The root is never a
picker/listing row; elicitation only engages when the root is not a
plausible target, which is what keeps the feature purely additive.

New settings_only_workspace fixture pins the vp-config shape non-TTY;
app_root_runnable_root now asserts in-place runs for both TTY modes.
The tty=false vite-build capture differs invisibly on Windows (same class
as the round-9 unanalyzable_config_ignored fix); settings_only_workspace
already covers the non-TTY in-place guarantee with Windows-stable tsdown
output.
…3 rebase

The rebase onto main (v0.2.3, vite-task 417b6aae) conflicted on the pin:
417b6aae ships vite_select and encoded_milestone but not the prompt param
the picker needs (#510 is still open). ca55bb232aa8 is #510's current head
(prompt param + main merged in), missing only two dependency-bump chores
from 417b6aae that don't touch the crates vp consumes. Final pin moves to
the merged SHA once #510 lands.
A bare app command walked find_workspace_root twice: once in classify to
decide whether to elicit, once in execute_direct_subcommand to set up the
session. classify now returns the root it found (Classification::RunInPlace)
whenever the command runs in the unchanged cwd, and the caller reuses it.

The hot path is a bare vp dev/build deep in a large monorepo: it now does
one upward walk instead of two, and still never enumerates packages (that
only happens at a root that actually elicits). The hint is handed back only
for the run-in-place cases, where the effective cwd equals the original, so
it always matches a fresh lookup; retargeted (-C, picker, defaultPackage)
paths still resolve the target's own root.
Fast-forward over the previous ca55bb23 pin (contains it), so no divergence.
The new head merged main, so it now carries main's v0.2.3 release commit
(417b6aae, the two dep-bump chores my earlier pin was missing) plus
ef49b4ac (fix: clarify task process wait errors, #515) and the v0.2.4 dep
bump. The prompt param the picker needs is unchanged; snapshots are
byte-stable. Final pin still moves to the merged SHA once #510 lands.
An interactive PTY case can wedge unbounded on the Namespace Linux runner
(PTY spawn/teardown flakiness under the parallel harness, the Linux analog
of the ConPTY blocking that keeps the Windows snapshot job on a
GitHub-hosted runner). The harness's 50s per-step timeout does not cover
TestTerminal::spawn, and the RwLock execution gate then stalls the whole
suite. With no job timeout that burned the 6h default and never flushed the
step log. A 10-min step bound (healthy runs finish in under 2 min) fails
fast and preserves the partial log so the wedged case is identifiable.
vpr had no snapshot coverage: add vpr -C <dir> <task> and its cd-form
equivalent (both print the target cwd, proving the global vpr shim and
local bin/vpr consume -C) plus bare vpr -C (missing-value error) to cwd_flag.

The dev/build/preview positional had only is_bare unit coverage: add
vp build apps/web to app_root_listing, proving a positional forwards to
Vite as [root] (output at apps/web/dist, cwd unchanged) and skips
elicitation entirely (no Selected/Tip lines), unlike the -C forms.
vite-task #510 (configurable selector prompt) merged, so move the pin off
the now-defunct PR-head commit (28310dc6) to the permanent main commit that
merged it. Content-identical for the crates vp consumes (empty tree diff on
vite_select/pty_terminal_test), so this is purely a stable-pin move: the
picker's prompt param and snapshots are unchanged.
Two regressions surfaced in review, each reproduced by a new snapshot fixture:

- defaultPackage declared through a TypeScript wrapper (satisfies UserConfig,
  as const) was ignored: extract_config_from_expr only stripped parentheses,
  so the wrapper made the config unanalyzable. Unwrap TS-only wrappers via
  get_inner_expression. Fixture default_package_ts_wrapper + extractor unit
  tests.
- a workspace root that builds via vite.config.ts (build.lib/SSR, no root
  index.html) was filtered out of elicitation once a member existed, so
  non-interactive vp build listed members instead of building the root.
  The root is now runnable when it declares a build block, not only when it
  has index.html (a shared lint/fmt config still declares neither). Fixture
  config_only_root_build.
The recurring Linux snapshot hang survives #2102's spawn bound (a run that
contained the bound still wedged for the full 10-min step timeout), so the
wedge is at a different, still-unbounded point (or cumulative create-test
timeouts around the ctrl-c case). Add snap_trace (gated on VP_SNAP_TRACE=1),
writing START/phase/END lines straight to fd 2 so they survive libtest
capture and reach the CI log even when the step is killed by its timeout. A
step that logs START without END is the wedged one, and the phase markers
(spawn dispatched / spawned / waiting for exit) localize within it. Enable
it on the Linux PTY CI step; off by default everywhere else.
…eview

Three review follow-ups, each reproduced first:

- unwrap TS wrappers INSIDE defineConfig too: defineConfig({...} satisfies
  UserConfig) and factory returns now analyze, not just the outer export.
  (default_package_define_config_wrapper + extractor unit tests)
- the declared-build root signal is build-only: a lib/SSR root is a target
  for vp build, but vp dev/preview still need index.html, so dev/preview at
  a lib root elicits instead of serving the root. (dev_at_lib_root_elicits)
- an explicit -c/--config file is an explicit target: vp build -c <cfg>
  forwards to Vite instead of eliciting. (explicit_config_forwards + is_bare
  unit tests)
The last hang confirmed the trace works (the mac job logged clean
START/END for every step) but GitHub discards a timed-out step's own log,
so the wedged Linux job's trace was unrecoverable. Write the trace to
VP_SNAP_TRACE_FILE instead and print it from a following if: always() step,
which runs even after the PTY step is killed by its timeout. The next
Linux wedge will show the START-without-END case in the Print PTY trace
step. VP_SNAP_TRACE=1 still goes to stderr for local runs.
config_only_root_build and explicit_config_forwards captured a vite build
with tty=false; a piped vite build's raw byte stream differs invisibly on
Windows (the round-9 unanalyzable_config_ignored class), so both failed the
Windows snapshot job. Render them through the PTY grid instead, which
normalizes the bytes; the assertions (root builds in place, -c config
forwards) are unchanged. dev_at_lib_root_elicits stays tty=false: it
captures the plain-text elicitation error, which is Windows-stable.
- vpr: restore dynamic import so it runs after enableCompileCache (a static
  import is hoisted, defeating the compile cache for the CLI bundle)
- static config: unwrap TS-only wrappers (as const, satisfies) on property
  values so defaultPackage: './x' as const stays a static string literal;
  add extractor unit tests and a value_wrapper snapshot fixture
- global -C: deliver PWD to delegated children via the spawn env instead of
  mutating our own PWD, which is unsound once the Tokio runtime has threads
The new expr_to_json TS-wrapper assertions exceeded the line width;
cargo fmt --check gates CI Lint.
Diagnostic scaffolding (snap_trace, VP_SNAP_TRACE_FILE, the Print PTY
trace step, and the 10-min step bound) for the Namespace-Linux PTY wedge.
The hang did not reproduce across 40 retry attempts, so this drops the
investigation code from the feature PR; restore ci.yml and the harness to
their base state.

fengmk2 commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b0ae86a517

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if !target.as_path().is_dir() {
return Err(format!("directory not found: {dir}"));
}
if let Err(e) = std::env::set_current_dir(target.as_path()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve PWD for -C package-manager children

For vp -C app install / vp -C app pm ..., this only changes the process cwd; the package-manager path goes through vite_pm_cli to vite_command::run_command, which inherits the existing environment and only overlays command-specific envs, so PWD still points at the original directory. Any npm/pnpm lifecycle script or child command that reads process.env.PWD observes the wrong project, so -C is not equivalent to cd app && vp ... for these commands. Please set PWD on spawned non-JS children when a -C target is applied.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

preview-build Publish this PR's commits to the registry bridge as preview builds test: create-e2e Run `vp create` e2e tests test: e2e Auto run e2e tests test: install-e2e run vite install e2e test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant